SpringAI - ChatClient(二)
更多的响应类型
响应String
- 即调用content()方法:就是上一篇案例中使用到的方法,直接返回模型响应回来的字符串内容。没有元数据、Tokens等信息。
call()
阻塞式调用和stream()
流式调用都有该类型的响应,后续其他响应类型未做特别说明的便为两种调用方式都支持。- 这里上篇案例中都用到了,这里不再写案例记录了。
响应ChatResponse/ChatClientResponse
ChatResponse
需调用chatResponse()方法:在ChatResponse中包含多组生成结果、元数据、Tokens等信息。
1
2
3
4
5
6
7
8
9ChatResponse deepseekChatResponse = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user(userMessage)
.call() // 或者.stream()
.chatResponse();
// 获取到输出内容
deepseekChatResponse.getResult().getOutput().getText();
// 元数据
deepseekChatResponse.getMetadata();下面是响应的结果输出的JSON(压缩的)。
1
{"result":{"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"STOP","index":0,"role":"ASSISTANT","id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","messageType":"ASSISTANT"},"toolCalls":[],"media":[],"prefix":null,"reasoningContent":null,"text":"JVM 采用**分代内存管理**(Generational Memory Management)的设计,**这里内容删减了** 这是对程序运行时行为的经验性优化,符合“二八法则”。"},"metadata":{"finishReason":"STOP","contentFilters":[],"empty":true}},"results":[{"output":{"messageType":"ASSISTANT","metadata":{"finishReason":"STOP","index":0,"role":"ASSISTANT","id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","messageType":"ASSISTANT"},"toolCalls":[],"media":[],"prefix":null,"reasoningContent":null,"text":"JVM 采用**分代内存管理**(Generational Memory Management)的设计,**这里内容删减了** 这是对程序运行时行为的经验性优化,符合“二八法则”。"},"metadata":{"finishReason":"STOP","contentFilters":[],"empty":true}}],"metadata":{"id":"26a2b93d-ba09-4b60-a1fb-e252290a3fe0","model":"deepseek-chat","rateLimit":{"requestsLimit":0,"requestsRemaining":0,"requestsReset":0.0,"tokensLimit":0,"tokensRemaining":0,"tokensReset":0.0},"usage":{"promptTokens":23,"completionTokens":583,"totalTokens":606,"nativeUsage":{"completion_tokens":583,"prompt_tokens":23,"total_tokens":606,"prompt_tokens_details":{"cached_tokens":0}}},"promptMetadata":[],"empty":false}}
这里测试过不同AI模型返回的ChatResponse结构是否一样,使用的Deepseek跟OpenAi。结论是大同小异,基本是保持一致的,OpenAi响应的ChatResponse会多几个字段。
ChatClientResponse
与ChatResponse类似,调用chatClientResponse()方法。
相比ChatResponse,ChatClientResponse则不仅包含ChatResponse,还多了ChatClient调用模型的上下文。
这里应该是因为我没有设置过Advisor,所以上下文是空的,这个后面在学习Advisor再仔细看看。
响应自定义的Java类
该响应方式仅在call()阻塞式调用支持。也很好理解,stream()流式调用每次响应的数据其实可以看成只有部分数据碎片。做结构化转换肯定是需要待数据全部响应后才可以转换。
通过调用
.entity
方法,该方法有三个不同参数的重载。
entity(Class type)
用于返回固定的实体类
案例:让AI”列出Java的JDK最近一个版本及其发布时间”
- 先定义一个实体类,包含version(版本)、releaseDate(发布时间)两个字段
- 这里
record
关键字是Java 16之后的新的特性,方便定义一些实体类。
1
2
3public record JdkVersionResponse(String version, LocalDate releaseDate) {
}- 调用
1
2
3
4
5
6
7
8
9
10
11
12private final ChatClient deepseekClient;
private static final String SYSTEM_PROMPT = "你是一个Java专家,请帮忙解答提出的Java相关问题。";
public void entity() {
JdkVersionResponse jdkVersionResponse = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user("列出Java的JDK最近一个版本及其发布时间")
.call()
.entity(JdkVersionResponse.class);
log.info("\nEntity jdkVersionResponse \n-> {}", ConvertorUtils.toJsonString(jdkVersionResponse));
}- 结果
从案例的结果中可以看到,响应的是我们自己定义的Java类,并且返回的输出时封装在对应的字段中的。
entity(ParameterizedTypeReference type)
用于返回一些存在泛型类型,比如
List<T>
案例:让AI”列出Java的JDK所有版本以及版本发布时间”
- 沿用上面的实体类即可,调用过程
1
2
3
4
5
6
7
8
9
10
11
12
13private final ChatClient deepseekClient;
private static final String SYSTEM_PROMPT = "你是一个Java专家,请帮忙解答提出的Java相关问题。";
public void entity() {
List<JdkVersionResponse> jdkVersionResponses = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user("列出Java的JDK所有版本以及版本发布时间")
.call()
.entity(new ParameterizedTypeReference<List<JdkVersionResponse>>() {});
log.info("\nEntity jdkVersionResponse list \n-> {}", ConvertorUtils.toJsonString(jdkVersionResponses));
}- 结果,下面是日志输出的Json数组
1
2Entity jdkVersionResponse list
-> [{"version":"JDK 1.0","releaseDate":"1996-01-23"},{"version":"JDK 1.1","releaseDate":"1997-02-19"},{"version":"J2SE 1.2","releaseDate":"1998-12-08"},{"version":"J2SE 1.3","releaseDate":"2000-05-08"},{"version":"J2SE 1.4","releaseDate":"2002-02-06"},{"version":"J2SE 5.0","releaseDate":"2004-09-30"},{"version":"Java SE 6","releaseDate":"2006-12-11"},{"version":"Java SE 7","releaseDate":"2011-07-28"},{"version":"Java SE 8","releaseDate":"2014-03-18"},{"version":"Java SE 9","releaseDate":"2017-09-21"},{"version":"Java SE 10","releaseDate":"2018-03-20"},{"version":"Java SE 11","releaseDate":"2018-09-25"},{"version":"Java SE 12","releaseDate":"2019-03-19"},{"version":"Java SE 13","releaseDate":"2019-09-17"},{"version":"Java SE 14","releaseDate":"2020-03-17"},{"version":"Java SE 15","releaseDate":"2020-09-15"},{"version":"Java SE 16","releaseDate":"2021-03-16"},{"version":"Java SE 17","releaseDate":"2021-09-14"},{"version":"Java SE 18","releaseDate":"2022-03-22"},{"version":"Java SE 19","releaseDate":"2022-09-20"},{"version":"Java SE 20","releaseDate":"2023-03-21"},{"version":"Java SE 21","releaseDate":"2023-09-19"}]从两个案例中可以看出,其实两个方法是类似的,只是如果需要保留返回类型中的泛型则需要选择第二种。
entity(StructuredOutputConverter converter)
用于通过
StructuredOutputConverter
转换器自行将String转换为目标类型。案例:这里让AI”给出Java的JDK使用最广泛的一个版本及其发布时间”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18Map<String, String> mapResponse = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user("给出Java的JDK使用最广泛的一个版本及其发布时间")
.call()
.entity(new StructuredOutputConverter<Map<String, String>>() {
public String getFormat() {
return "{\"jdk_version\" : \"jdk版本\", \"jdk_release_date\" : \"jdk发布日期\"}";
}
public Map<String, String> convert(String source) {
// 将String转换成Map
source = source.replaceAll("```json", "");
source = source.replaceAll("```", "");
return ConvertorUtils.parseJsonObject(source, Map.class);
}
});从上面代码中可以看到需要自定义一个
StructuredOutputConverter
,并重写其convert(String source)
方法,在里面实现转换过程。这里不放结果了,放在下面还有“自定义响应结构”里面了。
自定义响应结构
如何自定义响应结构
上面看到了自定义的
StructuredOutputConverter
,除了重写convert(String source)
方法,还重写了一个getFormat()
方法。这个方法就是自定义响应结构的关键了,先看看上面案例返回的结果。从结果上可以看到是按照指定的格式
"{\"jdk_version\" : \"jdk版本\", \"jdk_release_date\" : \"jdk发布日期\"}"
传入到convert(String source)
方法的。为什么上面还写了两行
.replaceAll
?最开始测试的时候给返回的是markdown代码块,格式如下面这样。这里应该是后面写了其他的案例,在写blog记录时去请求Deepseek命中了缓存,直接给去掉了markdown代码块只保留了内容。往后面继续看就知道。1
2
3
4
5
6```json
{
"jdk_version": "JDK 8 (1.8)",
"jdk_release_date": "2014年3月18日"
}
```
自定义结构出现的问题
上面有说到当时响应一直会出现````json markdown`代码块,虽然非常机智的用replaceAll解决了,但是有点low啊。
然后就好奇
entity(Class<T> type)
是怎么实现的,那么到这里基本上也能猜测到也是通过StructuredOutputConverter
来实现结构化响应的。那总不会也是用replaceAll去解决吧。然后就发现了一个
StructuredOutputConverter
的子类org.springframework.ai.converter.BeanOutputConverter
,急忙找他是如何实现getFormat()
方法的,一看原来是加了提示词。下面是源码1
2
3
4public String getFormat() {
String template = "Your response should be in JSON format.\nDo not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\nDo not include markdown code blocks in your response.\nRemove the ```json markdown from the output.\nHere is the JSON Schema instance your output must adhere to:\n```%s```\n";
return String.format(template, this.jsonSchema);
}在模版中要求了不允许有markdown代码块,输出中也必须删除 ```json markdown 等。
然后就有了另外一个案例,依葫芦画瓢这种事儿还是很擅长的,增加了返回格式要求的提示词(中文版),并且格式是数组格式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18List<String> listResponse = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user("给出Java的JDK使用最广泛的一个版本及其发布时间")
.call()
.entity(new StructuredOutputConverter<List<String>>() {
public String getFormat() {
String format = "你的响应格式必须按照下列给出格式输出。\n不需要做任何解释\n不要在响应中包含markdown代码块\n从输出中删除```json markdown\n以下是你的输出格式:\n```%s```\n";
return String.format(format, "[\"jdk版本\", \"jdk发布日期(YYYY_MM_DD格式)}\"]");
}
public List<String> convert(String source) {
return ConvertorUtils.parseJsonObject(source, List.class);
}
});
log.info("\nstructuredOutputConverter listResponse \n-> {}", ConvertorUtils.toJsonString(listResponse));输出的结果。案例的
convert(String source)
方法中是没做任何处理直接转换的。1
2structuredOutputConverter listResponse
-> [ "jdk8", "2014_03_18" ]
伟大都源于一个勇敢的尝试
既然Json都可以,那我就是不想使用Json,想自己另外定义一个格式。
就又有了一个案例,以
-
隔开两个需要的内容,就是讲究一个随便。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22Map<String, String> customizeResponse = deepseekClient.prompt()
.system(SYSTEM_PROMPT)
.user("给出Java的JDK使用最广泛的一个版本及其发布时间")
.call()
.entity(new StructuredOutputConverter<Map<String, String>>() {
public String getFormat() {
String format = "你的响应格式必须按照下列给出格式输出。\n不需要做任何解释\n不要在响应中包含markdown代码块\n从输出中删除```json markdown\n以下是你的输出格式:\n```%s```\n";
return String.format(format, "{jdk版本} - {jdk发布日期(YY/MM/DD格式)}");
}
public Map<String, String> convert(String source) {
String[] split = source.split(" - ");
return new HashMap<>(){{
put("v", split[0]);
put("date", split[1]);
}};
}
});
log.info("\nstructuredOutputConverter customizeResponse \n-> {}", ConvertorUtils.toJsonString(customizeResponse));输出结果。从结果上看,没有任何问题。
1
2
3
4
5structuredOutputConverter customizeResponse
-> {
"date" : "14/03/18",
"v" : "Java 8"
}
总结
- ChatClient 在调用模型后,提供了多种响应类型可选择,直接响应本文内容的、响应包含元数据信息的ChatResponse、响应包含上下文的ChatClientResponse。
- 还有响应实体类的entity方法,固定实体类的、带泛型的、自定义结构化转换器的。
- 可以通过自定义结构化转换器来自定义响应格式。
临时突发疑问
- 记录到最后突然有个疑问,既想要响应实体类、有想要返回元数据信息呢?主打一个既要有要。
- 看了一下代码,发现有个
.responseEntity
方法,参数与.entity
方法一样,有三个重载。返回的ResponseEntity
里面包含了ChatResponse、和实体类。 - SpringAI的文档上好像也没看到这个
.responseEntity
方法,与.entity
方法基本一致的,这里也不做记录案例了。
最后
- 从使用上看,
StructuredOutputConverter
似乎更灵活,也应该会更贴近开发使用。 - 简单看了一下源码,SpringAI封装了Bean\List\Map三个转换器,一般使用应该够了,特殊的还是得自己来封装。
- 在SpringAI文档上,还有一节专门的结构化输出的内容,后续还会再继续深入学习这一块。
- 案例没有特殊说明,默认都是基于Deepseek模型的。所有案例的源码,都会提交在GitHub上。包:
com.spring.ai.example.advisor.two